// @ts-check

const Cast = require('../util/cast');
const StringUtil = require('../util/string-util');
const BlockType = require('../extension-support/block-type');
const Variable = require('../engine/variable');
const log = require('../util/log');
const compatBlocks = require('./compat-blocks');
const {StackOpcode, InputOpcode, InputType} = require('./enums.js');
const {
    IntermediateStackBlock,
    IntermediateInput,
    IntermediateStack,
    IntermediateScript,
    IntermediateRepresentation
} = require('./intermediate');
const oldCompilerCompatiblity = require('./old-compiler-compatibility.js');

/**
 * @fileoverview Generate intermediate representations from Scratch blocks.
 */

/* eslint-disable max-len */

const SCALAR_TYPE = '';
const LIST_TYPE = 'list';

/**
 * @typedef DescendedVariable
 * @property {'target'|'stage'} scope
 * @property {string | null} id
 * @property {string} name
 * @property {boolean} isCloud
 */

/**
 * @param {string} code
 * @param {boolean} warp
 * @returns {string}
 */
const generateProcedureVariant = (code, warp) => {
    if (warp) {
        return `W${code}`;
    }
    return `Z${code}`;
};

/**
 * @param {string} variant Variant generated by generateProcedureVariant()
 * @returns {string} original procedure code
 */
const parseProcedureCode = variant => variant.substring(1);

/**
 * @param {string} variant Variant generated by generateProcedureVariant()
 * @returns {boolean} true if warp enabled
 */
const parseIsWarp = variant => variant.charAt(0) === 'W';

class ScriptTreeGenerator {
    constructor (thread) {
        /** @private */
        this.thread = thread;
        /** @private */
        this.target = thread.target;
        /** @private */
        this.blocks = thread.blockContainer;
        /** @private */
        this.runtime = this.target.runtime;
        /** @private */
        this.stage = this.runtime.getTargetForStage();

        /**
         * This script's intermediate representation.
         */
        this.script = new IntermediateScript();
        this.script.warpTimer = this.target.runtime.compilerOptions.warpTimer;

        /**
         * Cache of variable ID to variable data object.
         * @type {Object.<string, object>}
         * @private
         */
        this.variableCache = {};

        this.usesTimer = false;

        this.namesOfCostumesAndSounds = new Set();
        for (const target of this.runtime.targets) {
            if (target.isOriginal) {
                const sprite = target.sprite;
                for (const costume of sprite.costumes) {
                    this.namesOfCostumesAndSounds.add(costume.name);
                }
                for (const sound of sprite.sounds) {
                    this.namesOfCostumesAndSounds.add(sound.name);
                }
            }
        }

        this.oldCompilerStub = (
            oldCompilerCompatiblity.enabled ?
                new oldCompilerCompatiblity.ScriptTreeGeneratorStub(this) :
                null
        );
    }

    setProcedureVariant (procedureVariant) {
        const procedureCode = parseProcedureCode(procedureVariant);

        this.script.procedureVariant = procedureVariant;
        this.script.procedureCode = procedureCode;
        this.script.isProcedure = true;
        this.script.yields = false;

        const paramNamesIdsAndDefaults = this.blocks.getProcedureParamNamesIdsAndDefaults(procedureCode);
        if (paramNamesIdsAndDefaults === null) {
            throw new Error(`IR: cannot find procedure: ${procedureVariant}`);
        }

        const [paramNames, _paramIds, _paramDefaults] = paramNamesIdsAndDefaults;
        this.script.arguments = paramNames;
    }

    enableWarp () {
        this.script.isWarp = true;
    }

    getBlockById (blockId) {
        // Flyout blocks are stored in a special container.
        return this.blocks.getBlock(blockId) || this.blocks.runtime.flyoutBlocks.getBlock(blockId);
    }

    getBlockInfo (fullOpcode) {
        const [category, opcode] = StringUtil.splitFirst(fullOpcode, '_');
        if (!category || !opcode) {
            return null;
        }
        const categoryInfo = this.runtime._blockInfo.find(ci => ci.id === category);
        if (!categoryInfo) {
            return null;
        }
        const blockInfo = categoryInfo.blocks.find(b => b.info.opcode === opcode);
        if (!blockInfo) {
            return null;
        }
        return blockInfo;
    }

    createConstantInput (constant, preserveStrings = false) {
        if (constant === null) throw new Error('IR: Constant cannot have a null value.');

        constant += '';
        const numConstant = +constant;
        const preserve = preserveStrings && this.namesOfCostumesAndSounds.has(constant);

        if (!Number.isNaN(numConstant) && (constant.trim() !== '' || constant.includes('\t'))) {
            if (!preserve && numConstant.toString() === constant) {
                return new IntermediateInput(InputOpcode.CONSTANT, IntermediateInput.getNumberInputType(numConstant), {value: numConstant});
            }
            return new IntermediateInput(InputOpcode.CONSTANT, InputType.STRING_NUM, {value: constant});
        }

        if (!preserve) {
            if (constant === 'true') {
                return new IntermediateInput(InputOpcode.CONSTANT, InputType.STRING_BOOLEAN, {value: constant});
            } else if (constant === 'false') {
                return new IntermediateInput(InputOpcode.CONSTANT, InputType.STRING_BOOLEAN, {value: constant});
            }
        }

        return new IntermediateInput(InputOpcode.CONSTANT, InputType.STRING_NAN, {value: constant});
    }

    /**
     * Descend into a child input of a block. (eg. the input STRING of "length of ( )")
     * @param {*} parentBlock The parent Scratch block that contains the input.
     * @param {string} inputName The name of the input to descend into.
     * @param {boolean} preserveStrings Should this input keep the names of costumes and sounds at strings.
     * @private
     * @returns {IntermediateInput} Compiled input node for this input.
     */
    descendInputOfBlock (parentBlock, inputName, preserveStrings = false) {
        const input = parentBlock.inputs[inputName];
        if (!input) {
            log.warn(`IR: ${parentBlock.opcode}: missing input ${inputName}`, parentBlock);
            return this.createConstantInput(0);
        }
        const inputId = input.block;
        const block = this.getBlockById(inputId);
        if (!block) {
            log.warn(`IR: ${parentBlock.opcode}: could not find input ${inputName} with ID ${inputId}`);
            return this.createConstantInput(0);
        }

        const intermediate = this.descendInput(block, preserveStrings);
        this.script.yields = this.script.yields || intermediate.yields;
        return intermediate;
    }

    /**
     * Descend into an input. (eg. "length of ( )")
     * @param {*} block The parent Scratch block input.
     * @param {boolean} preserveStrings Should this input keep the names of costumes and sounds at strings.
     * @private
     * @returns {IntermediateInput} Compiled input node for this input.
     */
    descendInput (block, preserveStrings = false) {
        if (this.oldCompilerStub) {
            const oldCompilerResult = this.oldCompilerStub.descendInputFromNewCompiler(block);
            if (oldCompilerResult) {
                return oldCompilerResult;
            }
        }

        switch (block.opcode) {
        case 'colour_picker':
            return this.createConstantInput(block.fields.COLOUR.value, true);
        case 'math_angle':
        case 'math_integer':
        case 'math_number':
        case 'math_positive_number':
        case 'math_whole_number':
            return this.createConstantInput(block.fields.NUM.value, preserveStrings);
        case 'text':
            return this.createConstantInput(block.fields.TEXT.value, preserveStrings);
        case 'argument_reporter_string_number': {
            const name = block.fields.VALUE.value;
            // lastIndexOf because multiple parameters with the same name will use the value of the last definition
            const index = this.script.arguments.lastIndexOf(name);
            if (index === -1) {
                // Legacy support
                if (name.toLowerCase() === 'last key pressed') {
                    return new IntermediateInput(InputOpcode.TW_KEY_LAST_PRESSED, InputType.STRING);
                }
            }
            if (index === -1) {
                return this.createConstantInput(0);
            }
            return new IntermediateInput(InputOpcode.PROCEDURE_ARGUMENT, InputType.ANY, {index});
        }
        case 'argument_reporter_boolean': {
            // see argument_reporter_string_number above
            const name = block.fields.VALUE.value;
            const index = this.script.arguments.lastIndexOf(name);
            if (index === -1) {
                if (name.toLowerCase() === 'is compiled?' || name.toLowerCase() === 'is turbowarp?') {
                    return this.createConstantInput(true).toType(InputType.BOOLEAN);
                }
                return this.createConstantInput(0);
            }
            return new IntermediateInput(InputOpcode.PROCEDURE_ARGUMENT, InputType.ANY, {index});
        }

        case 'data_variable':
            return new IntermediateInput(InputOpcode.VAR_GET, InputType.ANY, {
                variable: this.descendVariable(block, 'VARIABLE', SCALAR_TYPE)
            });
        case 'data_itemoflist':
            return new IntermediateInput(InputOpcode.LIST_GET, InputType.ANY, {
                list: this.descendVariable(block, 'LIST', LIST_TYPE),
                index: this.descendInputOfBlock(block, 'INDEX')
            });
        case 'data_lengthoflist':
            return new IntermediateInput(InputOpcode.LIST_LENGTH, InputType.NUMBER_POS_REAL | InputType.NUMBER_ZERO, {
                list: this.descendVariable(block, 'LIST', LIST_TYPE)
            });
        case 'data_listcontainsitem':
            return new IntermediateInput(InputOpcode.LIST_CONTAINS, InputType.BOOLEAN, {
                list: this.descendVariable(block, 'LIST', LIST_TYPE),
                item: this.descendInputOfBlock(block, 'ITEM')
            });
        case 'data_itemnumoflist':
            return new IntermediateInput(InputOpcode.LIST_INDEX_OF, InputType.NUMBER_POS_REAL | InputType.NUMBER_ZERO, {
                list: this.descendVariable(block, 'LIST', LIST_TYPE),
                item: this.descendInputOfBlock(block, 'ITEM')
            });
        case 'data_listcontents':
            return new IntermediateInput(InputOpcode.LIST_CONTENTS, InputType.STRING, {
                list: this.descendVariable(block, 'LIST', LIST_TYPE)
            });

        case 'event_broadcast_menu': {
            const broadcastOption = block.fields.BROADCAST_OPTION;
            const broadcastVariable = this.target.lookupBroadcastMsg(broadcastOption.id, broadcastOption.value);
            // TODO: empty string probably isn't the correct fallback
            const broadcastName = broadcastVariable ? broadcastVariable.name : '';
            return this.createConstantInput(broadcastName);
        }

        case 'looks_backdropnumbername':
            if (block.fields.NUMBER_NAME.value === 'number') {
                return new IntermediateInput(InputOpcode.LOOKS_BACKDROP_NUMBER, InputType.NUMBER_POS_REAL);
            }
            return new IntermediateInput(InputOpcode.LOOKS_BACKDROP_NAME, InputType.STRING);
        case 'looks_costumenumbername':
            if (block.fields.NUMBER_NAME.value === 'number') {
                return new IntermediateInput(InputOpcode.LOOKS_COSTUME_NUMBER, InputType.NUMBER_POS_REAL);
            }
            return new IntermediateInput(InputOpcode.LOOKS_COSTUME_NAME, InputType.STRING);
        case 'looks_size':
            return new IntermediateInput(InputOpcode.LOOKS_SIZE_GET, InputType.NUMBER_POS);

        case 'motion_direction':
            return new IntermediateInput(InputOpcode.MOTION_DIRECTION_GET, InputType.NUMBER_REAL);
        case 'motion_xposition':
            return new IntermediateInput(InputOpcode.MOTION_X_GET, InputType.NUMBER);
        case 'motion_yposition':
            return new IntermediateInput(InputOpcode.MOTION_Y_GET, InputType.NUMBER);

        case 'operator_add':
            return new IntermediateInput(InputOpcode.OP_ADD, InputType.NUMBER_OR_NAN, {
                left: this.descendInputOfBlock(block, 'NUM1').toType(InputType.NUMBER),
                right: this.descendInputOfBlock(block, 'NUM2').toType(InputType.NUMBER)
            });
        case 'operator_and':
            return new IntermediateInput(InputOpcode.OP_AND, InputType.BOOLEAN, {
                left: this.descendInputOfBlock(block, 'OPERAND1').toType(InputType.BOOLEAN),
                right: this.descendInputOfBlock(block, 'OPERAND2').toType(InputType.BOOLEAN)
            });
        case 'operator_contains':
            return new IntermediateInput(InputOpcode.OP_CONTAINS, InputType.BOOLEAN, {
                string: this.descendInputOfBlock(block, 'STRING1').toType(InputType.STRING),
                contains: this.descendInputOfBlock(block, 'STRING2').toType(InputType.STRING)
            });
        case 'operator_divide':
            return new IntermediateInput(InputOpcode.OP_DIVIDE, InputType.NUMBER_OR_NAN, {
                left: this.descendInputOfBlock(block, 'NUM1').toType(InputType.NUMBER),
                right: this.descendInputOfBlock(block, 'NUM2').toType(InputType.NUMBER)
            });
        case 'operator_equals':
            return new IntermediateInput(InputOpcode.OP_EQUALS, InputType.BOOLEAN, {
                left: this.descendInputOfBlock(block, 'OPERAND1'),
                right: this.descendInputOfBlock(block, 'OPERAND2')
            });
        case 'operator_gt':
            return new IntermediateInput(InputOpcode.OP_GREATER, InputType.BOOLEAN, {
                left: this.descendInputOfBlock(block, 'OPERAND1'),
                right: this.descendInputOfBlock(block, 'OPERAND2')
            });
        case 'operator_join':
            return new IntermediateInput(InputOpcode.OP_JOIN, InputType.STRING, {
                left: this.descendInputOfBlock(block, 'STRING1').toType(InputType.STRING),
                right: this.descendInputOfBlock(block, 'STRING2').toType(InputType.STRING)
            });
        case 'operator_length':
            return new IntermediateInput(InputOpcode.OP_LENGTH, InputType.NUMBER_REAL, {
                string: this.descendInputOfBlock(block, 'STRING').toType(InputType.STRING)
            });
        case 'operator_letter_of':
            return new IntermediateInput(InputOpcode.OP_LETTER_OF, InputType.STRING, {
                letter: this.descendInputOfBlock(block, 'LETTER').toType(InputType.NUMBER_INDEX),
                string: this.descendInputOfBlock(block, 'STRING').toType(InputType.STRING)
            });
        case 'operator_lt':
            return new IntermediateInput(InputOpcode.OP_LESS, InputType.BOOLEAN, {
                left: this.descendInputOfBlock(block, 'OPERAND1'),
                right: this.descendInputOfBlock(block, 'OPERAND2')
            });
        case 'operator_mathop': {
            const value = this.descendInputOfBlock(block, 'NUM').toType(InputType.NUMBER);
            const operator = block.fields.OPERATOR.value.toLowerCase();
            switch (operator) {
            case 'abs': return new IntermediateInput(InputOpcode.OP_ABS, InputType.NUMBER_POS | InputType.NUMBER_ZERO, {value});
            case 'floor': return new IntermediateInput(InputOpcode.OP_FLOOR, InputType.NUMBER, {value});
            case 'ceiling': return new IntermediateInput(InputOpcode.OP_CEILING, InputType.NUMBER, {value});
            case 'sqrt': return new IntermediateInput(InputOpcode.OP_SQRT, InputType.NUMBER_OR_NAN, {value});
            case 'sin': return new IntermediateInput(InputOpcode.OP_SIN, InputType.NUMBER_OR_NAN, {value});
            case 'cos': return new IntermediateInput(InputOpcode.OP_COS, InputType.NUMBER_OR_NAN, {value});
            case 'tan': return new IntermediateInput(InputOpcode.OP_TAN, InputType.NUMBER_OR_NAN, {value});
            case 'asin': return new IntermediateInput(InputOpcode.OP_ASIN, InputType.NUMBER_OR_NAN, {value});
            case 'acos': return new IntermediateInput(InputOpcode.OP_ACOS, InputType.NUMBER_OR_NAN, {value});
            case 'atan': return new IntermediateInput(InputOpcode.OP_ATAN, InputType.NUMBER, {value});
            case 'ln': return new IntermediateInput(InputOpcode.OP_LOG_E, InputType.NUMBER_OR_NAN, {value});
            case 'log': return new IntermediateInput(InputOpcode.OP_LOG_10, InputType.NUMBER_OR_NAN, {value});
            case 'e ^': return new IntermediateInput(InputOpcode.OP_POW_E, InputType.NUMBER, {value});
            case '10 ^': return new IntermediateInput(InputOpcode.OP_POW_10, InputType.NUMBER, {value});
            default: return this.createConstantInput(0);
            }
        }
        case 'operator_mod':
            return new IntermediateInput(InputOpcode.OP_MOD, InputType.NUMBER_OR_NAN, {
                left: this.descendInputOfBlock(block, 'NUM1').toType(InputType.NUMBER),
                right: this.descendInputOfBlock(block, 'NUM2').toType(InputType.NUMBER)
            });
        case 'operator_multiply':
            return new IntermediateInput(InputOpcode.OP_MULTIPLY, InputType.NUMBER_OR_NAN, {
                left: this.descendInputOfBlock(block, 'NUM1').toType(InputType.NUMBER),
                right: this.descendInputOfBlock(block, 'NUM2').toType(InputType.NUMBER)
            });
        case 'operator_not':
            return new IntermediateInput(InputOpcode.OP_NOT, InputType.BOOLEAN, {
                operand: this.descendInputOfBlock(block, 'OPERAND').toType(InputType.BOOLEAN)
            });
        case 'operator_or':
            return new IntermediateInput(InputOpcode.OP_OR, InputType.BOOLEAN, {
                left: this.descendInputOfBlock(block, 'OPERAND1').toType(InputType.BOOLEAN),
                right: this.descendInputOfBlock(block, 'OPERAND2').toType(InputType.BOOLEAN)
            });
        case 'operator_random': {
            const from = this.descendInputOfBlock(block, 'FROM');
            const to = this.descendInputOfBlock(block, 'TO');
            // If both values are known at compile time, we can do some optimizations.
            // TODO: move optimizations to jsgen?
            if (from.opcode === InputOpcode.CONSTANT && to.opcode === InputOpcode.CONSTANT) {
                const sFrom = from.inputs.value;
                const sTo = to.inputs.value;
                const nFrom = Cast.toNumber(sFrom);
                const nTo = Cast.toNumber(sTo);
                // If both numbers are the same, random is unnecessary.
                // todo: this probably never happens so consider removing
                if (nFrom === nTo) {
                    return this.createConstantInput(nFrom);
                }
                // If both are ints, hint this to the compiler
                if (Cast.isInt(sFrom) && Cast.isInt(sTo)) {
                    // Both inputs are ints, so we know neither are NaN
                    return new IntermediateInput(InputOpcode.OP_RANDOM, InputType.NUMBER, {
                        low: (nFrom <= nTo ? from : to).toType(InputType.NUMBER),
                        high: (nFrom <= nTo ? to : from).toType(InputType.NUMBER),
                        useInts: true,
                        useFloats: false
                    });
                }
                // Otherwise hint that these are floats
                return new IntermediateInput(InputOpcode.OP_RANDOM, InputType.NUMBER_OR_NAN, {
                    low: (nFrom <= nTo ? from : to).toType(InputType.NUMBER),
                    high: (nFrom <= nTo ? to : from).toType(InputType.NUMBER),
                    useInts: false,
                    useFloats: true
                });
            } else if (from.opcode === InputOpcode.CONSTANT) {
                // If only one value is known at compile-time, we can still attempt some optimizations.
                if (!Cast.isInt(Cast.toNumber(from.inputs.value))) {
                    return new IntermediateInput(InputOpcode.OP_RANDOM, InputType.NUMBER_OR_NAN, {
                        low: from.toType(InputType.NUMBER),
                        high: to.toType(InputType.NUMBER),
                        useInts: false,
                        useFloats: true
                    });
                }
            } else if (to.opcode === InputOpcode.CONSTANT) {
                if (!Cast.isInt(Cast.toNumber(from.inputs.value))) {
                    return new IntermediateInput(InputOpcode.OP_RANDOM, InputType.NUMBER_OR_NAN, {
                        low: from.toType(InputType.NUMBER),
                        high: to.toType(InputType.NUMBER),
                        useInts: false,
                        useFloats: true
                    });
                }
            }
            // No optimizations possible
            return new IntermediateInput(InputOpcode.OP_RANDOM, InputType.NUMBER_OR_NAN, {
                low: from,
                high: to,
                useInts: false,
                useFloats: false
            });
        }
        case 'operator_round':
            return new IntermediateInput(InputOpcode.OP_ROUND, InputType.NUMBER, {
                value: this.descendInputOfBlock(block, 'NUM').toType(InputType.NUMBER)
            });
        case 'operator_subtract':
            return new IntermediateInput(InputOpcode.OP_SUBTRACT, InputType.NUMBER_OR_NAN, {
                left: this.descendInputOfBlock(block, 'NUM1').toType(InputType.NUMBER),
                right: this.descendInputOfBlock(block, 'NUM2').toType(InputType.NUMBER)
            });

        case 'procedures_call': {
            const procedureInfo = this.getProcedureInfo(block);
            return new IntermediateInput(procedureInfo.opcode, InputType.ANY, procedureInfo.inputs, procedureInfo.yields);
        }

        case 'sensing_answer':
            return new IntermediateInput(InputOpcode.SENSING_ANSWER, InputType.STRING);

        case 'sensing_coloristouchingcolor':
            return new IntermediateInput(InputOpcode.SENSING_COLOR_TOUCHING_COLOR, InputType.BOOLEAN, {
                target: this.descendInputOfBlock(block, 'COLOR2').toType(InputType.COLOR),
                mask: this.descendInputOfBlock(block, 'COLOR').toType(InputType.COLOR)
            });
        case 'sensing_current':
            switch (block.fields.CURRENTMENU.value.toLowerCase()) {
            case 'year': return new IntermediateInput(InputOpcode.SENSING_TIME_YEAR, InputType.NUMBER_POS_REAL | InputType.NUMBER_ZERO);
            case 'month': return new IntermediateInput(InputOpcode.SENSING_TIME_MONTH, InputType.NUMBER_POS_REAL);
            case 'date': return new IntermediateInput(InputOpcode.SENSING_TIME_DATE, InputType.NUMBER_POS_REAL);
            case 'dayofweek': return new IntermediateInput(InputOpcode.SENSING_TIME_WEEKDAY, InputType.NUMBER_POS_REAL);
            case 'hour': return new IntermediateInput(InputOpcode.SENSING_TIME_HOUR, InputType.NUMBER_POS_REAL | InputType.NUMBER_ZERO);
            case 'minute': return new IntermediateInput(InputOpcode.SENSING_TIME_MINUTE, InputType.NUMBER_POS_REAL | InputType.NUMBER_ZERO);
            case 'second': return new IntermediateInput(InputOpcode.SENSING_TIME_SECOND, InputType.NUMBER_POS_REAL | InputType.NUMBER_ZERO);
            default: return this.createConstantInput(0);
            }
        case 'sensing_dayssince2000':
            return new IntermediateInput(InputOpcode.SENSING_TIME_DAYS_SINCE_2000, InputType.NUMBER);
        case 'sensing_distanceto':
            return new IntermediateInput(InputOpcode.SENSING_DISTANCE, InputType.NUMBER_POS | InputType.NUMBER_ZERO, {
                target: this.descendInputOfBlock(block, 'DISTANCETOMENU').toType(InputType.STRING)
            });
        case 'sensing_keypressed':
            return new IntermediateInput(InputOpcode.SENSING_KEY_DOWN, InputType.BOOLEAN, {
                key: this.descendInputOfBlock(block, 'KEY_OPTION', true)
            });
        case 'sensing_mousedown':
            return new IntermediateInput(InputOpcode.SENSING_MOUSE_DOWN, InputType.BOOLEAN);
        case 'sensing_mousex':
            return new IntermediateInput(InputOpcode.SENSING_MOUSE_X, InputType.NUMBER);
        case 'sensing_mousey':
            return new IntermediateInput(InputOpcode.SENSING_MOUSE_Y, InputType.NUMBER);
        case 'sensing_of': {
            const property = block.fields.PROPERTY.value;
            const object = this.descendInputOfBlock(block, 'OBJECT').toType(InputType.STRING);

            if (object.opcode !== InputOpcode.CONSTANT) {
                return new IntermediateInput(InputOpcode.SENSING_OF, InputType.ANY, {object, property});
            }

            if (property === 'volume') {
                return new IntermediateInput(InputOpcode.SENSING_OF_VOLUME, InputType.NUMBER_POS_REAL | InputType.NUMBER_ZERO, {object, property});
            }

            if (object.isConstant('_stage_')) {
                switch (property) {
                case 'background #': // fallthrough for scratch 1.0 compatibility
                case 'backdrop #':
                    return new IntermediateInput(InputOpcode.SENSING_OF_BACKDROP_NUMBER, InputType.NUMBER_POS_REAL);
                case 'backdrop name':
                    return new IntermediateInput(InputOpcode.SENSING_OF_BACKDROP_NAME, InputType.STRING);
                }
            } else {
                switch (property) {
                case 'x position':
                    return new IntermediateInput(InputOpcode.SENSING_OF_POS_X, InputType.NUMBER_REAL, {object});
                case 'y position':
                    return new IntermediateInput(InputOpcode.SENSING_OF_POS_Y, InputType.NUMBER_REAL, {object});
                case 'direction':
                    return new IntermediateInput(InputOpcode.SENSING_OF_DIRECTION, InputType.NUMBER_REAL, {object});
                case 'costume #':
                    return new IntermediateInput(InputOpcode.SENSING_OF_COSTUME_NUMBER, InputType.NUMBER_POS_REAL, {object});
                case 'costume name':
                    return new IntermediateInput(InputOpcode.SENSING_OF_COSTUME_NAME, InputType.STRING, {object});
                case 'size':
                    return new IntermediateInput(InputOpcode.SENSING_OF_SIZE, InputType.NUMBER_POS_REAL, {object});
                }
            }

            return new IntermediateInput(InputOpcode.SENSING_OF_VAR, InputType.ANY, {object, property});
        }
        case 'sensing_timer':
            this.usesTimer = true;
            return new IntermediateInput(InputOpcode.SENSING_TIMER_GET, InputType.NUMBER_POS_REAL | InputType.NUMBER_ZERO);
        case 'sensing_touchingcolor':
            return new IntermediateInput(InputOpcode.SENSING_TOUCHING_COLOR, InputType.BOOLEAN, {
                color: this.descendInputOfBlock(block, 'COLOR').toType(InputType.COLOR)
            });
        case 'sensing_touchingobject':
            return new IntermediateInput(InputOpcode.SENSING_TOUCHING_OBJECT, InputType.BOOLEAN, {
                object: this.descendInputOfBlock(block, 'TOUCHINGOBJECTMENU')
            });
        case 'sensing_username':
            return new IntermediateInput(InputOpcode.SENSING_USERNAME, InputType.STRING);

        case 'sound_sounds_menu':
            // This menu is special compared to other menus -- it actually has an opcode function.
            return this.createConstantInput(block.fields.SOUND_MENU.value, true);

        case 'control_get_counter':
            return new IntermediateInput(InputOpcode.CONTROL_COUNTER, InputType.NUMBER_POS_INT | InputType.NUMBER_ZERO);

        case 'tw_getLastKeyPressed':
            return new IntermediateInput(InputOpcode.TW_KEY_LAST_PRESSED, InputType.STRING);

        default: {
            const opcodeFunction = this.runtime.getOpcodeFunction(block.opcode);
            if (opcodeFunction) {
                // It might be a non-compiled primitive from a standard category
                if (compatBlocks.inputs.includes(block.opcode)) {
                    return this.descendCompatLayerInput(block);
                }
                // It might be an extension block.
                const blockInfo = this.getBlockInfo(block.opcode);
                if (blockInfo) {
                    const type = blockInfo.info.blockType;
                    if (type === BlockType.REPORTER || type === BlockType.BOOLEAN) {
                        return this.descendCompatLayerInput(block);
                    }
                }
            }

            // It might be a menu.
            const inputs = Object.keys(block.inputs);
            const fields = Object.keys(block.fields);
            if (inputs.length === 0 && fields.length === 1) {
                return this.createConstantInput(block.fields[fields[0]].value, preserveStrings);
            }

            log.warn(`IR: Unknown input: ${block.opcode}`, block);
            throw new Error(`IR: Unknown input: ${block.opcode}`);
        }
        }
    }

    /**
     * Descend into a stacked block. (eg. "move ( ) steps")
     * @param {*} block The Scratch block to parse.
     * @private
     * @returns {IntermediateStackBlock} Compiled node for this block.
     */
    descendStackedBlock (block) {
        if (this.oldCompilerStub) {
            const oldCompilerResult = this.oldCompilerStub.descendStackedBlockFromNewCompiler(block);
            if (oldCompilerResult) {
                return oldCompilerResult;
            }
        }

        switch (block.opcode) {
        case 'control_all_at_once':
            // In Scratch 3, this block behaves like "if 1 = 1"
            return new IntermediateStackBlock(StackOpcode.CONTROL_IF_ELSE, {
                condition: this.createConstantInput(true).toType(InputType.BOOLEAN),
                whenTrue: this.descendSubstack(block, 'SUBSTACK'),
                whenFalse: new IntermediateStack()
            });
        case 'control_create_clone_of':
            return new IntermediateStackBlock(StackOpcode.CONTROL_CLONE_CREATE, {
                target: this.descendInputOfBlock(block, 'CLONE_OPTION').toType(InputType.STRING)
            });
        case 'control_delete_this_clone':
            return new IntermediateStackBlock(StackOpcode.CONTROL_CLONE_DELETE, {}, true);
        case 'control_forever':
            return new IntermediateStackBlock(StackOpcode.CONTROL_WHILE, {
                condition: this.createConstantInput(true).toType(InputType.BOOLEAN),
                do: this.descendSubstack(block, 'SUBSTACK')
            }, this.analyzeLoop());
        case 'control_for_each':
            return new IntermediateStackBlock(StackOpcode.CONTROL_FOR, {
                variable: this.descendVariable(block, 'VARIABLE', SCALAR_TYPE),
                count: this.descendInputOfBlock(block, 'VALUE').toType(InputType.NUMBER),
                do: this.descendSubstack(block, 'SUBSTACK')
            }, this.analyzeLoop());
        case 'control_if':
            return new IntermediateStackBlock(StackOpcode.CONTROL_IF_ELSE, {
                condition: this.descendInputOfBlock(block, 'CONDITION').toType(InputType.BOOLEAN),
                whenTrue: this.descendSubstack(block, 'SUBSTACK'),
                whenFalse: new IntermediateStack()
            });
        case 'control_if_else':
            return new IntermediateStackBlock(StackOpcode.CONTROL_IF_ELSE, {
                condition: this.descendInputOfBlock(block, 'CONDITION').toType(InputType.BOOLEAN),
                whenTrue: this.descendSubstack(block, 'SUBSTACK'),
                whenFalse: this.descendSubstack(block, 'SUBSTACK2')
            });
        case 'control_repeat':
            return new IntermediateStackBlock(StackOpcode.CONTROL_REPEAT, {
                times: this.descendInputOfBlock(block, 'TIMES').toType(InputType.NUMBER),
                do: this.descendSubstack(block, 'SUBSTACK')
            }, this.analyzeLoop());
        case 'control_repeat_until': {
            // Dirty hack: automatically enable warp timer for this block if it uses timer
            // This fixes project that do things like "repeat until timer > 0.5"
            this.usesTimer = false;
            const condition = this.descendInputOfBlock(block, 'CONDITION');
            const needsWarpTimer = this.usesTimer;
            return new IntermediateStackBlock(StackOpcode.CONTROL_WHILE, {
                condition: new IntermediateInput(InputOpcode.OP_NOT, InputType.BOOLEAN, {
                    operand: condition
                }),
                do: this.descendSubstack(block, 'SUBSTACK'),
                warpTimer: needsWarpTimer
            }, this.analyzeLoop() || needsWarpTimer);
        }
        case 'control_stop': {
            const level = block.fields.STOP_OPTION.value;
            if (level === 'all') {
                return new IntermediateStackBlock(StackOpcode.CONTROL_STOP_ALL, {}, true);
            } else if (level === 'other scripts in sprite' || level === 'other scripts in stage') {
                return new IntermediateStackBlock(StackOpcode.CONTROL_STOP_OTHERS);
            } else if (level === 'this script') {
                return new IntermediateStackBlock(StackOpcode.CONTROL_STOP_SCRIPT);
            }
            return new IntermediateStackBlock(StackOpcode.NOP);
        }
        case 'control_wait':
            return new IntermediateStackBlock(StackOpcode.CONTROL_WAIT, {
                seconds: this.descendInputOfBlock(block, 'DURATION').toType(InputType.NUMBER)
            }, true);
        case 'control_wait_until':
            return new IntermediateStackBlock(StackOpcode.CONTROL_WAIT_UNTIL, {
                condition: this.descendInputOfBlock(block, 'CONDITION').toType(InputType.BOOLEAN)
            }, true);
        case 'control_while':
            return new IntermediateStackBlock(StackOpcode.CONTROL_WHILE, {
                condition: this.descendInputOfBlock(block, 'CONDITION').toType(InputType.BOOLEAN),
                do: this.descendSubstack(block, 'SUBSTACK'),
                // We should consider analyzing this like we do for control_repeat_until
                warpTimer: false
            }, this.analyzeLoop());
        case 'control_clear_counter':
            return new IntermediateStackBlock(StackOpcode.CONTROL_CLEAR_COUNTER);
        case 'control_incr_counter':
            return new IntermediateStackBlock(StackOpcode.CONTORL_INCR_COUNTER);

        case 'data_addtolist':
            return new IntermediateStackBlock(StackOpcode.LIST_ADD, {
                list: this.descendVariable(block, 'LIST', LIST_TYPE),
                item: this.descendInputOfBlock(block, 'ITEM', true)
            });
        case 'data_changevariableby': {
            const variable = this.descendVariable(block, 'VARIABLE', SCALAR_TYPE);
            return new IntermediateStackBlock(StackOpcode.VAR_SET, {
                variable,
                value: new IntermediateInput(InputOpcode.OP_ADD, InputType.NUMBER_OR_NAN, {
                    left: new IntermediateInput(InputOpcode.VAR_GET, InputType.ANY, {variable}).toType(InputType.NUMBER),
                    right: this.descendInputOfBlock(block, 'VALUE').toType(InputType.NUMBER)
                })
            });
        }
        case 'data_deletealloflist':
            return new IntermediateStackBlock(StackOpcode.LIST_DELETE_ALL, {
                list: this.descendVariable(block, 'LIST', LIST_TYPE)
            });
        case 'data_deleteoflist': {
            const index = this.descendInputOfBlock(block, 'INDEX');
            if (index.isConstant('all')) {
                return new IntermediateStackBlock(StackOpcode.LIST_DELETE_ALL, {
                    list: this.descendVariable(block, 'LIST', LIST_TYPE)
                });
            }
            return new IntermediateStackBlock(StackOpcode.LIST_DELETE, {
                list: this.descendVariable(block, 'LIST', LIST_TYPE),
                index: index
            });
        }
        case 'data_hidelist':
            return new IntermediateStackBlock(StackOpcode.LIST_HIDE, {
                list: this.descendVariable(block, 'LIST', LIST_TYPE)
            });
        case 'data_hidevariable':
            return new IntermediateStackBlock(StackOpcode.VAR_HIDE, {
                variable: this.descendVariable(block, 'VARIABLE', SCALAR_TYPE)
            });
        case 'data_insertatlist':
            return new IntermediateStackBlock(StackOpcode.LIST_INSERT, {
                list: this.descendVariable(block, 'LIST', LIST_TYPE),
                index: this.descendInputOfBlock(block, 'INDEX'),
                item: this.descendInputOfBlock(block, 'ITEM', true)
            });
        case 'data_replaceitemoflist':
            return new IntermediateStackBlock(StackOpcode.LIST_REPLACE, {
                list: this.descendVariable(block, 'LIST', LIST_TYPE),
                index: this.descendInputOfBlock(block, 'INDEX'),
                item: this.descendInputOfBlock(block, 'ITEM', true)
            });
        case 'data_setvariableto':
            return new IntermediateStackBlock(StackOpcode.VAR_SET, {
                variable: this.descendVariable(block, 'VARIABLE', SCALAR_TYPE),
                value: this.descendInputOfBlock(block, 'VALUE', true)
            });
        case 'data_showlist':
            return new IntermediateStackBlock(StackOpcode.LIST_SHOW, {
                list: this.descendVariable(block, 'LIST', LIST_TYPE)
            });
        case 'data_showvariable':
            return new IntermediateStackBlock(StackOpcode.VAR_SHOW, {
                variable: this.descendVariable(block, 'VARIABLE', SCALAR_TYPE)
            });

        case 'event_broadcast':
            return new IntermediateStackBlock(StackOpcode.EVENT_BROADCAST, {
                broadcast: this.descendInputOfBlock(block, 'BROADCAST_INPUT').toType(InputType.STRING)
            });
        case 'event_broadcastandwait':
            return new IntermediateStackBlock(StackOpcode.EVENT_BROADCAST_AND_WAIT, {
                broadcast: this.descendInputOfBlock(block, 'BROADCAST_INPUT').toType(InputType.STRING)
            }, true);

        case 'looks_changeeffectby':
            return new IntermediateStackBlock(StackOpcode.LOOKS_EFFECT_CHANGE, {
                effect: block.fields.EFFECT.value.toLowerCase(),
                value: this.descendInputOfBlock(block, 'CHANGE').toType(InputType.NUMBER)
            });
        case 'looks_changesizeby':
            return new IntermediateStackBlock(StackOpcode.LOOKS_SIZE_CHANGE, {
                size: this.descendInputOfBlock(block, 'CHANGE').toType(InputType.NUMBER)
            });
        case 'looks_cleargraphiceffects':
            return new IntermediateStackBlock(StackOpcode.LOOKS_EFFECT_CLEAR);
        case 'looks_goforwardbackwardlayers':
            if (block.fields.FORWARD_BACKWARD.value === 'forward') {
                return new IntermediateStackBlock(StackOpcode.LOOKS_LAYER_FORWARD, {
                    layers: this.descendInputOfBlock(block, 'NUM').toType(InputType.NUMBER)
                });
            }
            return new IntermediateStackBlock(StackOpcode.LOOKS_LAYER_BACKWARD, {
                layers: this.descendInputOfBlock(block, 'NUM').toType(InputType.NUMBER)
            });
        case 'looks_gotofrontback':
            if (block.fields.FRONT_BACK.value === 'front') {
                return new IntermediateStackBlock(StackOpcode.LOOKS_LAYER_FRONT);
            }
            return new IntermediateStackBlock(StackOpcode.LOOKS_LAYER_BACK);
        case 'looks_hide':
            return new IntermediateStackBlock(StackOpcode.LOOKS_HIDE);
        case 'looks_nextbackdrop':
            return new IntermediateStackBlock(StackOpcode.LOOKS_BACKDROP_NEXT);
        case 'looks_nextcostume':
            return new IntermediateStackBlock(StackOpcode.LOOKS_COSTUME_NEXT);
        case 'looks_seteffectto':
            return new IntermediateStackBlock(StackOpcode.LOOKS_EFFECT_SET, {
                effect: block.fields.EFFECT.value.toLowerCase(),
                value: this.descendInputOfBlock(block, 'VALUE').toType(InputType.NUMBER)
            });
        case 'looks_setsizeto':
            return new IntermediateStackBlock(StackOpcode.LOOKS_SIZE_SET, {
                size: this.descendInputOfBlock(block, 'SIZE').toType(InputType.NUMBER)
            });
        case 'looks_show':
            return new IntermediateStackBlock(StackOpcode.LOOKS_SHOW);
        case 'looks_switchbackdropto':
            return new IntermediateStackBlock(StackOpcode.LOOKS_BACKDROP_SET, {
                backdrop: this.descendInputOfBlock(block, 'BACKDROP', true)
            });
        case 'looks_switchcostumeto':
            return new IntermediateStackBlock(StackOpcode.LOOKS_COSTUME_SET, {
                costume: this.descendInputOfBlock(block, 'COSTUME', true)
            });

        case 'motion_changexby':
            return new IntermediateStackBlock(StackOpcode.MOTION_X_CHANGE, {
                dx: this.descendInputOfBlock(block, 'DX').toType(InputType.NUMBER)
            });
        case 'motion_changeyby':
            return new IntermediateStackBlock(StackOpcode.MOTION_Y_CHANGE, {
                dy: this.descendInputOfBlock(block, 'DY').toType(InputType.NUMBER)
            });
        case 'motion_gotoxy':
            return new IntermediateStackBlock(StackOpcode.MOTION_XY_SET, {
                x: this.descendInputOfBlock(block, 'X').toType(InputType.NUMBER),
                y: this.descendInputOfBlock(block, 'Y').toType(InputType.NUMBER)
            });
        case 'motion_ifonedgebounce':
            return new IntermediateStackBlock(StackOpcode.MOTION_IF_ON_EDGE_BOUNCE);
        case 'motion_movesteps':
            return new IntermediateStackBlock(StackOpcode.MOTION_STEP, {
                steps: this.descendInputOfBlock(block, 'STEPS').toType(InputType.NUMBER)
            });
        case 'motion_pointindirection':
            return new IntermediateStackBlock(StackOpcode.MOTION_DIRECTION_SET, {
                direction: this.descendInputOfBlock(block, 'DIRECTION').toType(InputType.NUMBER)
            });
        case 'motion_setrotationstyle':
            return new IntermediateStackBlock(StackOpcode.MOTION_ROTATION_STYLE_SET, {
                style: block.fields.STYLE.value
            });
        case 'motion_setx':
            return new IntermediateStackBlock(StackOpcode.MOTION_X_SET, {
                x: this.descendInputOfBlock(block, 'X').toType(InputType.NUMBER)
            });
        case 'motion_sety':
            return new IntermediateStackBlock(StackOpcode.MOTION_Y_SET, {
                y: this.descendInputOfBlock(block, 'Y').toType(InputType.NUMBER)
            });
        case 'motion_turnleft':
            return new IntermediateStackBlock(StackOpcode.MOTION_DIRECTION_SET, {
                direction: new IntermediateInput(InputOpcode.OP_SUBTRACT, InputType.NUMBER, {
                    left: new IntermediateInput(InputOpcode.MOTION_DIRECTION_GET, InputType.NUMBER),
                    right: this.descendInputOfBlock(block, 'DEGREES').toType(InputType.NUMBER)
                })
            });
        case 'motion_turnright':
            return new IntermediateStackBlock(StackOpcode.MOTION_DIRECTION_SET, {
                direction: new IntermediateInput(InputOpcode.OP_ADD, InputType.NUMBER, {
                    left: new IntermediateInput(InputOpcode.MOTION_DIRECTION_GET, InputType.NUMBER),
                    right: this.descendInputOfBlock(block, 'DEGREES').toType(InputType.NUMBER)
                })
            });

        case 'pen_clear':
            return new IntermediateStackBlock(StackOpcode.PEN_CLEAR);
        case 'pen_changePenColorParamBy':
            return new IntermediateStackBlock(StackOpcode.PEN_COLOR_PARAM_CHANGE, {
                param: this.descendInputOfBlock(block, 'COLOR_PARAM').toType(InputType.STRING),
                value: this.descendInputOfBlock(block, 'VALUE').toType(InputType.NUMBER)
            });
        case 'pen_changePenHueBy':
            return new IntermediateStackBlock(StackOpcode.PEN_COLOR_HUE_CHANGE_LEGACY, {
                hue: this.descendInputOfBlock(block, 'HUE').toType(InputType.NUMBER)
            });
        case 'pen_changePenShadeBy':
            return new IntermediateStackBlock(StackOpcode.PEN_COLOR_SHADE_CHANGE_LEGACY, {
                shade: this.descendInputOfBlock(block, 'SHADE').toType(InputType.NUMBER)
            });
        case 'pen_penDown':
            return new IntermediateStackBlock(StackOpcode.PEN_DOWN);
        case 'pen_penUp':
            return new IntermediateStackBlock(StackOpcode.PEN_UP);
        case 'pen_setPenColorParamTo':
            return new IntermediateStackBlock(StackOpcode.PEN_COLOR_PARAM_SET, {
                param: this.descendInputOfBlock(block, 'COLOR_PARAM').toType(InputType.STRING),
                value: this.descendInputOfBlock(block, 'VALUE').toType(InputType.NUMBER)
            });
        case 'pen_setPenColorToColor':
            return new IntermediateStackBlock(StackOpcode.PEN_COLOR_SET, {
                color: this.descendInputOfBlock(block, 'COLOR')
            });
        case 'pen_setPenHueToNumber':
            return new IntermediateStackBlock(StackOpcode.PEN_COLOR_HUE_SET_LEGACY, {
                hue: this.descendInputOfBlock(block, 'HUE').toType(InputType.NUMBER)
            });
        case 'pen_setPenShadeToNumber':
            return new IntermediateStackBlock(StackOpcode.PEN_COLOR_SHADE_SET_LEGACY, {
                shade: this.descendInputOfBlock(block, 'SHADE').toType(InputType.NUMBER)
            });
        case 'pen_setPenSizeTo':
            return new IntermediateStackBlock(StackOpcode.PEN_SIZE_SET, {
                size: this.descendInputOfBlock(block, 'SIZE').toType(InputType.NUMBER)
            });
        case 'pen_changePenSizeBy':
            return new IntermediateStackBlock(StackOpcode.PEN_SIZE_CHANGE, {
                size: this.descendInputOfBlock(block, 'SIZE').toType(InputType.NUMBER)
            });
        case 'pen_stamp':
            return new IntermediateStackBlock(StackOpcode.PEN_STAMP);

        case 'procedures_call': {
            const procedureCode = block.mutation.proccode;

            if (block.mutation.return) {
                const visualReport = this.descendVisualReport(block);
                if (visualReport) {
                    return visualReport;
                }
            }

            if (procedureCode === 'tw:debugger;') {
                return new IntermediateStackBlock(StackOpcode.DEBUGGER);
            }

            const procedure = this.getProcedureInfo(block);
            return new IntermediateStackBlock(procedure.opcode, procedure.inputs, procedure.yields);
        }
        case 'procedures_return':
            return new IntermediateStackBlock(StackOpcode.PROCEDURE_RETURN, {
                value: this.descendInputOfBlock(block, 'VALUE')
            });

        case 'sensing_resettimer':
            return new IntermediateStackBlock(StackOpcode.SENSING_TIMER_RESET);

        default: {
            const opcodeFunction = this.runtime.getOpcodeFunction(block.opcode);
            if (opcodeFunction) {
                // It might be a non-compiled primitive from a standard category
                if (compatBlocks.stacked.includes(block.opcode)) {
                    return this.descendCompatLayerStack(block);
                }
                // It might be an extension block.
                const blockInfo = this.getBlockInfo(block.opcode);
                if (blockInfo) {
                    const type = blockInfo.info.blockType;
                    if (type === BlockType.COMMAND || type === BlockType.CONDITIONAL || type === BlockType.LOOP) {
                        return this.descendCompatLayerStack(block);
                    }
                }
            }

            const asVisualReport = this.descendVisualReport(block);
            if (asVisualReport) {
                return asVisualReport;
            }

            log.warn(`IR: Unknown stacked block: ${block.opcode}`, block);
            throw new Error(`IR: Unknown stacked block: ${block.opcode}`);
        }
        }
    }

    /**
     * Descend into a stack of blocks (eg. the blocks contained within an "if" block)
     * @param {*} parentBlock The parent Scratch block that contains the stack to parse.
     * @param {string} substackName The name of the stack to descend into.
     * @private
     * @returns {IntermediateStack} Stacked blocks.
     */
    descendSubstack (parentBlock, substackName) {
        const input = parentBlock.inputs[substackName];
        if (!input) {
            return new IntermediateStack();
        }
        const stackId = input.block;
        return this.walkStack(stackId);
    }

    /**
     * Descend into and walk the siblings of a stack.
     * @param {string} startingBlockId The ID of the first block of a stack.
     * @private
     * @returns {IntermediateStack} List of stacked block nodes.
     */
    walkStack (startingBlockId) {
        const result = new IntermediateStack();
        let blockId = startingBlockId;

        while (blockId !== null) {
            const block = this.getBlockById(blockId);
            if (!block) {
                break;
            }

            const node = this.descendStackedBlock(block);
            this.script.yields = this.script.yields || node.yields;
            result.blocks.push(node);

            blockId = block.next;
        }

        return result;
    }

    /**
     * @param {*} block
     * @returns {{
     *  opcode: StackOpcode & InputOpcode,
     *  inputs?: *,
     *  yields: boolean
     * }}
     */
    getProcedureInfo (block) {
        const procedureCode = block.mutation.proccode;
        const paramNamesIdsAndDefaults = this.blocks.getProcedureParamNamesIdsAndDefaults(procedureCode);

        if (paramNamesIdsAndDefaults === null) {
            return {opcode: StackOpcode.NOP, yields: false};
        }

        const [paramNames, paramIds, paramDefaults] = paramNamesIdsAndDefaults;

        const addonBlock = this.runtime.getAddonBlock(procedureCode);
        if (addonBlock) {
            const args = {};
            for (let i = 0; i < paramIds.length; i++) {
                let value;
                if (block.inputs[paramIds[i]] && block.inputs[paramIds[i]].block) {
                    value = this.descendInputOfBlock(block, paramIds[i], true);
                } else {
                    value = this.createConstantInput(paramDefaults[i], true);
                }
                args[paramNames[i]] = value;
            }

            return {
                opcode: StackOpcode.ADDON_CALL,
                inputs: {
                    code: procedureCode,
                    arguments: args,
                    blockId: block.id
                },
                yields: true
            };
        }

        const definitionId = this.blocks.getProcedureDefinition(procedureCode);
        const definitionBlock = this.blocks.getBlock(definitionId);
        if (!definitionBlock) {
            return {opcode: StackOpcode.NOP, yields: false};
        }
        const innerDefinition = this.blocks.getBlock(definitionBlock.inputs.custom_block.block);

        let isWarp = this.script.isWarp;
        if (!isWarp) {
            if (innerDefinition && innerDefinition.mutation) {
                const warp = innerDefinition.mutation.warp;
                if (typeof warp === 'boolean') {
                    isWarp = warp;
                } else if (typeof warp === 'string') {
                    isWarp = JSON.parse(warp);
                }
            }
        }

        const variant = generateProcedureVariant(procedureCode, isWarp);

        if (!this.script.dependedProcedures.includes(variant)) {
            this.script.dependedProcedures.push(variant);
        }

        const args = [];
        for (let i = 0; i < paramIds.length; i++) {
            let value;
            if (block.inputs[paramIds[i]] && block.inputs[paramIds[i]].block) {
                value = this.descendInputOfBlock(block, paramIds[i], true);
            } else {
                value = this.createConstantInput(paramDefaults[i], true);
            }
            args.push(value);
        }

        return {
            opcode: StackOpcode.PROCEDURE_CALL,
            inputs: {
                code: procedureCode,
                variant,
                arguments: args
            },
            yields: !this.script.isWarp && procedureCode === this.script.procedureCode
        };
    }

    /**
     * @param {*} block
     * @returns {IntermediateStackBlock | null}
     */
    descendVisualReport (block) {
        if (!this.thread.stackClick || block.next) {
            return null;
        }
        try {
            return new IntermediateStackBlock(StackOpcode.VISUAL_REPORT, {
                input: this.descendInput(block)
            });
        } catch (e) {
            return null;
        }
    }

    /**
     * Descend into a variable.
     * @param {*} block The block that has the variable.
     * @param {string} fieldName The name of the field that the variable is stored in.
     * @param {''|'list'} type Variable type, '' for scalar and 'list' for list.
     * @private
     * @returns {*} A parsed variable object.
     */
    descendVariable (block, fieldName, type) {
        const variable = block.fields[fieldName];
        const id = variable.id;

        if (id && Object.prototype.hasOwnProperty.call(this.variableCache, id)) {
            return this.variableCache[id];
        }

        const data = this._descendVariable(id, variable.value, type);
        // If variable ID was null, this might do some unnecessary updates, but that is a rare
        // edge case and it won't have any adverse effects anyways.
        this.variableCache[String(data.id)] = data;
        return data;
    }

    /**
     * @param {string|null} id The ID of the variable.
     * @param {string} name The name of the variable.
     * @param {''|'list'} type The variable type.
     * @private
     * @returns {DescendedVariable} A parsed variable object.
     */
    _descendVariable (id, name, type) {
        const target = this.target;
        const stage = this.stage;

        // Look for by ID in target...
        if (Object.prototype.hasOwnProperty.call(target.variables, id)) {
            const currVar = target.variables[String(id)];
            return {
                scope: 'target',
                id: currVar.id,
                name: currVar.name,
                isCloud: currVar.isCloud
            };
        }

        // Look for by ID in stage...
        if (!target.isStage) {
            if (stage && Object.prototype.hasOwnProperty.call(stage.variables, id)) {
                const currVar = stage.variables[String(id)];
                return {
                    scope: 'stage',
                    id: currVar.id,
                    name: currVar.name,
                    isCloud: currVar.isCloud
                };
            }
        }

        // Look for by name and type in target...
        for (const varId in target.variables) {
            if (Object.prototype.hasOwnProperty.call(target.variables, varId)) {
                const currVar = target.variables[varId];
                if (currVar.name === name && currVar.type === type) {
                    return {
                        scope: 'target',
                        id: currVar.id,
                        name: currVar.name,
                        isCloud: currVar.isCloud
                    };
                }
            }
        }

        // Look for by name and type in stage...
        if (!target.isStage && stage) {
            for (const varId in stage.variables) {
                if (Object.prototype.hasOwnProperty.call(stage.variables, varId)) {
                    const currVar = stage.variables[varId];
                    if (currVar.name === name && currVar.type === type) {
                        return {
                            scope: 'stage',
                            id: currVar.id,
                            name: currVar.name,
                            isCloud: currVar.isCloud
                        };
                    }
                }
            }
        }

        // Create it locally...
        const newVariable = new Variable(id, name, type, false);

        // Intentionally not using newVariable.id so that this matches vanilla Scratch quirks regarding
        // handling of null variable IDs.
        target.variables[String(id)] = newVariable;

        if (target.sprite) {
            // Create the variable in all instances of this sprite.
            // This is necessary because the script cache is shared between clones.
            // sprite.clones has all instances of this sprite including the original and all clones
            for (const clone of target.sprite.clones) {
                if (!Object.prototype.hasOwnProperty.call(clone.variables, id)) {
                    clone.variables[String(id)] = new Variable(id, name, type, false);
                }
            }
        }

        return {
            scope: 'target',
            // If the given ID was null, this won't match the .id property of the Variable object.
            // This is intentional to match vanilla Scratch quirks.
            id,
            name: newVariable.name,
            isCloud: newVariable.isCloud
        };
    }

    /**
     * Descend into an input block that uses the compatibility layer.
     * @param {*} block The block to use the compatibility layer for.
     * @private
     * @returns {IntermediateInput} The parsed node.
     */
    descendCompatLayerInput (block) {
        const inputs = {};
        const fields = {};
        for (const name of Object.keys(block.inputs)) {
            inputs[name] = this.descendInputOfBlock(block, name, true);
        }
        for (const name of Object.keys(block.fields)) {
            fields[name] = block.fields[name].value;
        }
        return new IntermediateInput(InputOpcode.COMPATIBILITY_LAYER, InputType.ANY, {
            opcode: block.opcode,
            id: block.id,
            inputs,
            fields
        }, true);
    }

    /**
     * Descend into a stack block that uses the compatibility layer.
     * @param {*} block The block to use the compatibility layer for.
     * @private
     * @returns {IntermediateStackBlock} The parsed node.
     */
    descendCompatLayerStack (block) {
        const inputs = {};
        for (const name of Object.keys(block.inputs)) {
            if (!name.startsWith('SUBSTACK')) {
                inputs[name] = this.descendInputOfBlock(block, name, true);
            }
        }

        const fields = {};
        for (const name of Object.keys(block.fields)) {
            fields[name] = block.fields[name].value;
        }

        const blockInfo = this.getBlockInfo(block.opcode);
        const blockType = (blockInfo && blockInfo.info && blockInfo.info.blockType) || BlockType.COMMAND;
        const substacks = {};
        if (blockType === BlockType.CONDITIONAL || blockType === BlockType.LOOP) {
            for (const inputName in block.inputs) {
                if (!inputName.startsWith('SUBSTACK')) continue;
                const branchNum = inputName === 'SUBSTACK' ? 1 : +inputName.substring('SUBSTACK'.length);
                if (!isNaN(branchNum)) {
                    substacks[branchNum] = this.descendSubstack(block, inputName);
                }
            }
        }

        return new IntermediateStackBlock(StackOpcode.COMPATIBILITY_LAYER, {
            opcode: block.opcode,
            id: block.id,
            blockType,
            inputs,
            fields,
            substacks
        }, true);
    }

    analyzeLoop () {
        return !this.script.isWarp || this.script.warpTimer;
    }

    readTopBlockComment (commentId) {
        const comment = this.target.comments[commentId];
        if (!comment) {
            // can't find the comment
            // this is safe to ignore
            return;
        }

        const text = comment.text;

        for (const line of text.split('\n')) {
            if (!/^tw\b/.test(line)) {
                continue;
            }

            const flags = line.split(' ');
            for (const flag of flags) {
                switch (flag) {
                case 'nocompile':
                    throw new Error('Script explicitly disables compilation');
                case 'stuck':
                    this.script.warpTimer = true;
                    break;
                }
            }

            // Only the first 'tw' line is parsed.
            break;
        }
    }

    /**
     * @param {*} hatBlock
     * @returns {IntermediateStack}
     */
    walkHat (hatBlock) {
        const nextBlock = hatBlock.next;
        const opcode = hatBlock.opcode;
        const hatInfo = this.runtime._hats[opcode];

        if (this.thread.stackClick) {
            // We still need to treat the hat as a normal block (so executableHat should be false) for
            // interpreter parity, but the reuslt is ignored.
            const opcodeFunction = this.runtime.getOpcodeFunction(opcode);
            if (opcodeFunction) {
                return new IntermediateStack([
                    this.descendCompatLayerStack(hatBlock),
                    ...this.walkStack(nextBlock).blocks
                ]);
            }
            return this.walkStack(nextBlock);
        }

        if (hatInfo.edgeActivated) {
            // Edge-activated HAT
            this.script.yields = true;
            this.script.executableHat = true;
            return new IntermediateStack([
                new IntermediateStackBlock(StackOpcode.HAT_EDGE, {
                    id: hatBlock.id,
                    condition: this.descendCompatLayerInput(hatBlock).toType(InputType.BOOLEAN)
                }),
                ...this.walkStack(nextBlock).blocks
            ]);
        }

        const opcodeFunction = this.runtime.getOpcodeFunction(opcode);
        if (opcodeFunction) {
            // Predicate-based HAT
            this.script.yields = true;
            this.script.executableHat = true;
            return new IntermediateStack([
                new IntermediateStackBlock(StackOpcode.HAT_PREDICATE, {
                    condition: this.descendCompatLayerInput(hatBlock).toType(InputType.BOOLEAN)
                }),
                ...this.walkStack(nextBlock).blocks
            ]);
        }

        return this.walkStack(nextBlock);
    }

    /**
     * @param {string} topBlockId The ID of the top block of the script.
     * @returns {IntermediateScript}
     */
    generate (topBlockId) {
        this.blocks.populateProcedureCache();

        this.script.topBlockId = topBlockId;

        const topBlock = this.getBlockById(topBlockId);
        if (!topBlock) {
            if (this.script.isProcedure) {
                // Empty procedure
                return this.script;
            }
            throw new Error('Cannot find top block');
        }

        if (topBlock.comment) {
            this.readTopBlockComment(topBlock.comment);
        }

        // We do need to evaluate empty hats
        const hatInfo = this.runtime._hats[topBlock.opcode];
        const isHat = !!hatInfo;
        if (isHat) {
            this.script.stack = this.walkHat(topBlock);
        } else {
            // We don't evaluate the procedures_definition top block as it never does anything
            // We also don't want it to be treated like a hat block
            let entryBlock;
            if (topBlock.opcode === 'procedures_definition') {
                entryBlock = topBlock.next;
            } else {
                entryBlock = topBlockId;
            }

            if (entryBlock) {
                this.script.stack = this.walkStack(entryBlock);
            }
        }

        return this.script;
    }
}

class IRGenerator {
    constructor (thread) {
        this.thread = thread;
        this.blocks = thread.blockContainer;

        this.proceduresToCompile = new Map();
        this.compilingProcedures = new Map();
        /** @type {Object.<string, IntermediateScript>} */
        this.procedures = {};

        this.analyzedProcedures = [];
    }

    addProcedureDependencies (dependencies) {
        for (const procedureVariant of dependencies) {
            if (Object.prototype.hasOwnProperty.call(this.procedures, procedureVariant)) {
                continue;
            }
            if (this.compilingProcedures.has(procedureVariant)) {
                continue;
            }
            if (this.proceduresToCompile.has(procedureVariant)) {
                continue;
            }
            const procedureCode = parseProcedureCode(procedureVariant);
            const definition = this.blocks.getProcedureDefinition(procedureCode);
            this.proceduresToCompile.set(procedureVariant, definition);
        }
    }

    /**
     * @param {ScriptTreeGenerator} generator The generator to run.
     * @param {string} topBlockId The ID of the top block in the stack.
     * @returns {IntermediateScript} Intermediate script.
     */
    generateScriptTree (generator, topBlockId) {
        const result = generator.generate(topBlockId);
        this.addProcedureDependencies(result.dependedProcedures);
        return result;
    }

    /**
     * Recursively analyze a script and its dependencies.
     * @param {IntermediateScript} script Intermediate script.
     */
    analyzeScript (script) {
        let madeChanges = false;
        for (const procedureCode of script.dependedProcedures) {
            const procedureData = this.procedures[procedureCode];

            // Analyze newly found procedures.
            if (!this.analyzedProcedures.includes(procedureCode)) {
                this.analyzedProcedures.push(procedureCode);
                if (this.analyzeScript(procedureData)) {
                    madeChanges = true;
                }
                this.analyzedProcedures.pop();
            }

            // If a procedure used by a script may yield, the script itself may yield.
            if (procedureData.yields && !script.yields) {
                script.yields = true;
                madeChanges = true;
            }
        }
        return madeChanges;
    }

    /**
     * @returns {IntermediateRepresentation} Intermediate representation.
     */
    generate () {
        const entry = this.generateScriptTree(new ScriptTreeGenerator(this.thread), this.thread.topBlock);

        // Compile any required procedures.
        // As procedures can depend on other procedures, this process may take several iterations.
        const procedureTreeCache = this.blocks._cache.compiledProcedures;
        while (this.proceduresToCompile.size > 0) {
            this.compilingProcedures = this.proceduresToCompile;
            this.proceduresToCompile = new Map();

            for (const [procedureVariant, definitionId] of this.compilingProcedures.entries()) {
                if (procedureTreeCache[procedureVariant]) {
                    const result = procedureTreeCache[procedureVariant];
                    this.procedures[procedureVariant] = result;
                    this.addProcedureDependencies(result.dependedProcedures);
                } else {
                    const isWarp = parseIsWarp(procedureVariant);
                    const generator = new ScriptTreeGenerator(this.thread);
                    generator.setProcedureVariant(procedureVariant);
                    if (isWarp) generator.enableWarp();
                    const compiledProcedure = this.generateScriptTree(generator, definitionId);
                    this.procedures[procedureVariant] = compiledProcedure;
                    procedureTreeCache[procedureVariant] = compiledProcedure;
                }
            }
        }

        // Analyze scripts until no changes are made.
        while (this.analyzeScript(entry));

        return new IntermediateRepresentation(entry, this.procedures);
    }
}

module.exports = {
    ScriptTreeGenerator,
    IRGenerator
};
